%load_ext autoreload
%autoreload 2
%matplotlib inline
En este notebook se entrenan varios modelos Autoencoer Convolucionales sobre el conjunto de patches obtenidos en el paso anterior. Primero se ajustarán CAEs con el objetivo de extraer un conjunto reducido de variables latentes que definan las imágenes, los resultados de esto son analizados en mayor profundidad en el siguiente notebook. El segundo objetivo es entrenar dos CAEs, uno con slides tumorales y otro con normales y evaluar los errores de reconstrucción sobre cada uno de los conjuntos.
Para entender mejor sobre el uso del CAE y la metodolgÃa que se sigue en este notebook se recomienda leer previamente los ejemplos con el dataset de MNIST, disponibles en los notebooks CAE_example_mnist_1.ipynb y CAE_example_mnist_2.ipynb.
Librerias
import yaml
import os
import re
import pandas as pd
from sklearn.model_selection import train_test_split
from keras.callbacks import TensorBoard, EarlyStopping
from keras.preprocessing.image import ImageDataGenerator
from model.cae import CAE
from utils import plot_paired_imgs
from utils import plot_sample_imgs
from utils import read_images
Configuración
with open('conf/user_conf.yaml', 'r') as f:
conf = yaml.load(f)
patches_path = os.path.join(conf['data_path'], 'slides', 'patches')
models_path = os.path.join(conf['data_path'], 'models')
image_size = conf['wsi']['patch_size']
Lectura de DataFrames
slides_df = pd.read_csv(os.path.join(conf['data_path'], 'slides_metadata.csv'), sep='|')
slide_to_patch_columns = ['case_id', 'sample_id', 'slide_id', 'disease_type', 'sample_type',
'percent_normal_cells', 'percent_stromal_cells', 'percent_tumor_cells', 'percent_tumor_nuclei']
El primer paso para entrenar un modelo es la división de los datos en los conjuntos de train y test, de esta manera siempre se guarda una parte de los datos que el modelo 'no habrá visto' para evaluar su rendimiento. En este caso, esta división no se va a hacer aleatoria sobre el conjunto de patches si no que se hará la división sobre el conjunto de slides. El motivo es que dos patches de una misma imagen pueden ser muy similares al estar uno al lado de otro y por tanto la evaluación serÃa engañosa.
Además, puesto que se analizarán las variables latentes generadas con respecto al cáncer es conveniente manetener el ratio de imágenes tumorales y sanas en los conjuntos de train y test, para ello se hace la divisón estratificada por esa columna.
slides_df = slides_df[slides_df['sample_type'].isin(['Primary Tumor', 'Solid Tissue Normal'])]
slides_df['sample_type'].value_counts(normalize=True)
slides_train, slides_test = train_test_split(slides_df, test_size=0.2,
stratify=slides_df['sample_type'], random_state=conf['seed'])
slides_train['sample_type'].value_counts(normalize=True)
slides_test['sample_type'].value_counts(normalize=True)
A continuación se crean los DataFrames de train y test a nivel patch. Se lee el directorio de imágenes y, a partir del nombre, se extrae el ID de la slide al que corresponde.
Obtención de los DataFrames
regex_slide_id = re.compile('.*(TCGA.*)_\d+_\d+\.png')
patches_df = []
for file_name in os.listdir(patches_path):
if not file_name.endswith('.png'):
continue
slide_id = regex_slide_id.match(file_name).groups()[0]
patches_df.append({'slide_id': slide_id, 'filename': file_name})
patches_df = pd.DataFrame(patches_df)
patches_df.head(5)
Se añaden los metadatos a de cada slide a sus patches.
training_patches_df = patches_df.merge(slides_train[slide_to_patch_columns], on='slide_id').sample(frac=1, random_state=conf['seed'])
test_patches_df = patches_df.merge(slides_test[slide_to_patch_columns], on='slide_id').sample(frac=1, random_state=conf['seed'])
Divisón de train y test en conjuntos sanos y tumorales. Será necesario en el último apartado cuando se entrenen modelos independedientes. Aquà es importante recordar que, aunque un patch esté marcado como tumoral esto quiere decir que pertenece a una slide que tumoral. Como el tumor estará localizado en una zona concreta del tejido y no en toda la slide, un patch marcado como tumoral puede se realmente un tejido sano.
training_patches_tumor_df = training_patches_df[training_patches_df['sample_type'] == 'Primary Tumor']
training_patches_normal_df = training_patches_df[training_patches_df['sample_type'] == 'Solid Tissue Normal']
test_patches_tumor_df = test_patches_df[test_patches_df['sample_type'] == 'Primary Tumor']
test_patches_normal_df = test_patches_df[test_patches_df['sample_type'] == 'Solid Tissue Normal']
training_patches_tumor_df.head(3)
training_patches_normal_df.head(3)
Muestras de imágenes
Tejido Tumoral
images_tumor_sample = read_images(training_patches_tumor_df['filename'].iloc[:20], patches_path)
plot_sample_imgs(images_tumor_sample, n_rows=2, n_cols=6, shuffle=False)
Tejido Sano
images_normal_sample = read_images(training_patches_normal_df['filename'].iloc[:20], patches_path)
plot_sample_imgs(images_normal_sample, n_rows=2, n_cols=6, shuffle=False)
Guardado
Se guardan los dataframes en ficheros CSV por si fuera necesario reutilizarlos y que no haga falta ejecutar todo el proceso anterior.
training_patches_df.to_csv(os.path.join(conf['data_path'], 'train.csv'), sep='|', index=False)
test_patches_df.to_csv(os.path.join(conf['data_path'], 'test.csv'), sep='|', index=False)
En total se tienen aproximadamente 75mil imagenes de 128x128 pÃxeles, esto equivale a 2.5GB en memoria. Para evitar cargar todas las imágenes a la vez se utlizará la herramienta de ImageDataGenerator.
Importante: hay que reescalar las imágenes entre 0 y 1 ya que por defecto cada pÃxel toma el nivel de escala de 8 bits, es decir, un valor entero entre 0 y 255.
train_datagen = ImageDataGenerator(validation_split=0.2, rescale=1/255)
train_generator = train_datagen.flow_from_dataframe(training_patches_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
subset='training', seed=conf['seed'])
validation_generator = train_datagen.flow_from_dataframe(training_patches_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
subset='validation', seed=conf['seed'])
test_datagen = ImageDataGenerator(rescale=1/255)
test_generator = test_datagen.flow_from_dataframe(test_patches_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
seed=conf['seed'])
La arquitectura del modelo está definida en el fichero de configuración.
conf['model']['cae']
cae = CAE(input_shape=(image_size, image_size, 3),
path=os.path.join(models_path, 'model_example'),
**conf['model']['cae'])
tb = TensorBoard(log_dir='../logs/model_example',
write_grads=True, write_images=True, histogram_freq=0)
En este caso a modo de ejemplo se entrena el modelo durante 4 epocs.
EPOCHS = 4
cae.model.fit_generator(generator = train_generator,
validation_data = validation_generator,
steps_per_epoch = len(train_generator),
validation_steps = len(validation_generator),
epochs = EPOCHS,
callbacks = [tb],
workers=8)
cae.save()
Evaluación
El MSE sobre los datos de test es similar al de los datos de training y validación por lo que se descarta overfitting.
cae.model.evaluate_generator(test_generator, steps=len(test_generator))
Muestras de salida
X_train = next(train_generator)[0]
X_train_out = cae.model.predict(X_train)
plot_paired_imgs(X_train, X_train_out, N=6)
X_test = next(test_generator)[0]
X_test_out = cae.model.predict(X_test)
plot_paired_imgs(X_test, X_test_out, N=6)
Si se desea entrenar varios modelos y dejar la máquina ejecutándose durante un largo periodo de tiempo sin supervisión se recomienda utilizar este paso. En una lista de diccionarios se definen las arquitecturas de cada uno de los modelos a entrenar y se da un id. El proceso automáticamente entrenará cada uno de las redes y lo guardará en la ruta indicada con el nombre model_id. Además, se puede ver en TensorBoard el proceso de entrenamiento.
Se ha añadido el callback EarlyStopping para reducir el periodo de entrenamiento. Las redes se entrenan durante un total de 30 epochs o hasta que haya 3 epochs seguidas sin mejora en la validación (parémtro patience).
models = [{'id': 1, 'filters': [8,16,32], 'latent_features': 256},
{'id': 2, 'filters': [8,16,32], 'latent_features': 512},
{'id': 3, 'filters': [8,16,32], 'latent_features': 1024},
{'id': 4, 'filters': [8,16,32,64], 'latent_features': 512}]
EPOCHS = 30
for model in models:
print()
cae = CAE(input_shape=(image_size, image_size, 3),
latent_features=model['latent_features'],
filters=model['filters'],
path=os.path.join(models_path, 'model_{}'.format(model['id'])))
tb = TensorBoard(log_dir='../logs/model_{}'.format(model['id']),
write_grads=True, write_images=True, histogram_freq=0)
es = EarlyStopping(monitor='val_loss', min_delta=0, patience=3, verbose=0, mode='auto')
cae.model.fit_generator(generator = train_generator,
validation_data = validation_generator,
steps_per_epoch = len(train_generator),
validation_steps = len(validation_generator),
epochs = EPOCHS,
callbacks = [tb, es],
workers = 12)
cae.save()
En este apartado se entrenan dos CAE por separado, uno con datos de slides tumorales y otro con datos de slides normales. La idea es repetir el proceso mostrado en el ejemplo 2 de MNIST, es decir, predecir el tipo de slide a la que pertenece un patch a partir del error de reconstrucción con dada uno de los CAEs entrenados.
Una limitación importante y que afectará a los resultados es que el conjunto tumoral tendrá un porcentaje alto de patches sanos porque las muestras estás etiquetadas a nivel slide.
Balanceo de datos
Para evitar que un modelo tenga mejor performance que otro debido a la cantidad de muestras se samplea conjunto de muestras tumorales para tener el mismo número que no tumorales.
training_patches_tumor_df = training_patches_tumor_df.sample(n=len(training_patches_normal_df),
random_state=conf['seed'])
test_patches_tumor_df = training_patches_tumor_df.sample(n=len(test_patches_normal_df),
random_state=conf['seed'])
len(training_patches_tumor_df)
len(training_patches_normal_df)
Lectores de imágenes
De nuevo se crean los objetos generadores de imágenes para no cargar todas en memoria.
train_datagen = ImageDataGenerator(validation_split=0.2, rescale=1/255)
test_datagen = ImageDataGenerator(rescale=1/255)
Tumor
train_tumor_generator = train_datagen.flow_from_dataframe(training_patches_tumor_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
subset='training', seed=conf['seed'])
validation_tumor_generator = train_datagen.flow_from_dataframe(training_patches_tumor_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
subset='validation', seed=conf['seed'])
test_datagen = ImageDataGenerator(rescale=1/255)
test_tumor_generator = test_datagen.flow_from_dataframe(test_patches_tumor_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
seed=conf['seed'])
Normal
train_normal_generator = train_datagen.flow_from_dataframe(training_patches_normal_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
subset='training', seed=conf['seed'])
validation_normal_generator = train_datagen.flow_from_dataframe(training_patches_normal_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
subset='validation', seed=conf['seed'])
test_normal_generator = test_datagen.flow_from_dataframe(test_patches_normal_df, directory=patches_path, x_col='filename',
target_size=(image_size, image_size), color_mode='rgb', class_mode='input',
seed=conf['seed'])
Entrenamiento de los modelos
En este apartado se hamn explorado varias opciones de aquitectura y todas con resultados similares. Por un lado se probaron una arquitecturas con una capa de variables latentes pequeña, 50 o 100 neuronas con la idea de que, como se mostraba en el dataset de MNIST, esa capa final fuera muy especÃfica al conjunto de entradas. Como los resultados no fueron se hizo justo lo contrario, entrenar un modelo muy complejo con 4 capas convolucionales y sin capa de variables latentes. Al no tener la capa de variables latentes no se reduce al dimensionalidad y por tanto la reconstrucción tiene un error muy bajo. Si hubiera diferencias importante entre el conjunto de imágenes tumorales y las normales la reconstrucción no serÃa tan buena en el conjunto que el modelo "no ha visto".
EPOCHS = 20
cae_tumor = CAE(input_shape=(image_size, image_size, 3), latent_features=None, filters=[8,16,32,64],
path=os.path.join(models_path, 'model_tumor'), load=True)
cae_normal = CAE(input_shape=(image_size, image_size, 3), latent_features=None, filters=[8,16,32,64],
path=os.path.join(models_path, 'model_normal'), load=True)
tb = TensorBoard(log_dir='../logs/model_tumor',
write_grads=True, write_images=True, histogram_freq=0)
cae_tumor.model.fit_generator(generator = train_tumor_generator,
validation_data = validation_tumor_generator,
steps_per_epoch = len(train_tumor_generator),
validation_steps = len(validation_tumor_generator),
epochs = EPOCHS,
callbacks = [tb])
cae_tumor.save()
tb = TensorBoard(log_dir='../logs/model_normal',
write_grads=True, write_images=True, histogram_freq=0)
cae_normal.model.fit_generator(generator = train_normal_generator,
validation_data = validation_normal_generator,
steps_per_epoch = len(train_normal_generator),
validation_steps = len(validation_normal_generator),
epochs = EPOCHS,
callbacks = [tb])
cae_normal.save()
Evaluación de errores de reconstrucción
Los errores de reconstrucción son siempre mejor con el modelo entrenado con datos tumorales, es más, el modelo de datos normales tiene mejor rendimiento con los datos tumorales. Esto hace pensar que el ruido que introducen todas las imágenes erróneamente etiquedadas hace que ambos modelos aprendan a reconstruir este tipo de imágenes sin problema. Otra de las posibles causas es el número de muestras, aunque 1000 muestras de test es un número razonable, todas las de tejido normal están generadas a partir de 8 slides únicamente, cualquier pequeña diferencia respecto al resto que tenga una de estas 8 slides puede afectar a los resultados.
Tumor data - Tumor model
cae_tumor.model.evaluate_generator(test_tumor_generator, steps=len(test_tumor_generator))
Tumor data - Normal Model
cae_normal.model.evaluate_generator(test_tumor_generator, steps=len(test_tumor_generator))
Normal data - Tumor model
cae_tumor.model.evaluate_generator(test_normal_generator, steps=len(test_normal_generator))
Normal data - Normal Model
cae_normal.model.evaluate_generator(test_normal_generator, steps=len(test_normal_generator))
Ejemplos
En los siguientes ejemplos no se puede apreciar a simple vista diferencias entre los resultados con unos modelos y con otros.
X_test_tumor = next(test_tumor_generator)[0]
X_test_normal = next(test_normal_generator)[0]
Tumor data - Tumor model
X_test_tumor_cae_tumor = cae_tumor.model.predict(X_test_tumor)
plot_paired_imgs(X_test_tumor, X_test_tumor_cae_tumor, N=8, shuffle=False, size=2.5)
Tumor data - Normal model
X_test_tumor_cae_normal = cae_normal.model.predict(X_test_tumor)
plot_paired_imgs(X_test_tumor, X_test_tumor_cae_normal, N=8, shuffle=False, size=2.5)
Normal data - Tumor Model
X_test_normal_cae_tumor = cae_tumor.model.predict(X_test_normal)
plot_paired_imgs(X_test_normal, X_test_normal_cae_tumor, N=8, shuffle=False, size=2.5)
Normal data - Normal model
X_test_normal_cae_normal = cae_tumor.model.predict(X_test_normal)
plot_paired_imgs(X_test_normal, X_test_normal_cae_normal, N=8, shuffle=False, size=2.5)